iT邦幫忙

2026 iThome 鐵人賽

DAY 19
1

💡 今日學習目標:理解安全設定缺陷 (Security Misconfiguration) 為什麼在 2025 年衝上第二名,並實際動手把該加的 HTTP 安全標頭加上去、把該刪的刪掉,最後用工具驗收成果。


📌 前言:從第 5 名衝到第 2 名的那一類

2021 年它排 A05,2025 年直接跳到 A02

看一下 OWASP 官方的數字就知道為什麼:這個類別涵蓋 16 個 CWE,平均發生率 3.00%,但最高發生率高達 27.70%。換句話說,在某些檢測項目上,每四個受測應用程式就有一個中招。累計出現次數是 719,084 次

而這一類最特別的地方在於:

它多半不是「寫錯程式」,而是「忘了設定」。

沒有人寫了一個有漏洞的函式,只是有人開了預設值就直接上線。Nginx、Apache、Express 的預設組態,設計目標是「相容性好、方便除錯」,從來就不是「安全」。

這也代表一件好事:這是 OWASP 十大裡面投入最少、拿分最快的一類。今天這篇你照著做,一個下午就能看到分數變化。

而我們今天要處理的主題,OWASP 在官方描述裡直接列成了一條判斷指標:

「The server does not send security headers or directives, or they are not set to secure values.」
(伺服器沒有送出安全標頭,或標頭的值設得不安全。)


🔍 核心觀念:安全標頭不是一張清單,是三件不同的事

大部分文章會給你一張「五大必備標頭」清單,然後你抄完就結束了。但那樣你只做了三分之二的工作,而且最重要的那一項多半做錯了

HTTP 安全標頭的三種類型:一行搞定的、需要動腦的 CSP、以及該刪掉的

真正該有的分類是這樣:

  1. 一行搞定的:貼上去就有效,沒有藉口不設。
  2. 需要動腦的:只有 CSP 一個,而且它是唯一會擋到你自己網站的那個。
  3. 該刪掉的:很多文章只教你「加什麼」,卻沒告訴你有些東西該拿掉

下面我們一項一項做。


🛠️ Step 1:先量測,不要憑感覺

動手改設定之前,先知道自己現在幾分。這樣改完才有對照。

打開 https://securityheaders.com,輸入網址,按下 Scan。

我們先掃 owasp.org,就是出版 OWASP Top 10 的那個組織:

securityheaders.com 掃描 owasp.org 的結果:等級 A、六個安全標頭全部具備,但 Warning 欄寫著 Grade capped at A,下方 Warnings 區塊指出其 CSP 含有 unsafe-inline 與 unsafe-eval

六個標頭全綠,拿到 A。 但先別急著羨慕,畫面上有兩個地方值得停一下:

  • Warning: Grade capped at A — 這個 A 是被上限鎖住的。不是它拿不到 A+,是工具刻意不給。
  • 下方的 Warnings 區塊說明了原因:它的 CSP 含有 unsafe-inlineunsafe-eval

連 OWASP 自己的網站都是這樣。 請先記住這個畫面,Step 3 我們會回來算這筆帳。

再看一個對照組。掃 ithelp.ithome.com.tw,也就是你現在正在讀這篇文章的地方:

securityheaders.com 掃描 ithelp.ithome.com.tw 的結果:等級 C,具備 HSTS 等三項,缺少 Content-Security-Policy、Referrer-Policy 與 Permissions-Policy

C。 缺的三項是 Content-Security-PolicyReferrer-PolicyPermissions-Policy,而這三項今天全部都會處理到:後兩項 Step 2 就補得起來,CSP 則是 Step 3 的整個主題。

你可以自己多掃幾個常去的網站,你會發現拿 F 的比想像中多很多。

⚠️ 提醒一下securityheaders.com 是第三方服務,你送進去的網址會被記錄,預設還會列進它公開的最近掃描清單。

掃自己的正式站或公司內部系統時,務必勾選 Hide results(上面兩張截圖裡都勾了);掃公開網站則無所謂。另外,掃描別人的站之前,先確認你有權限這樣做


🛠️ Step 2:把「一行搞定」的那幾個加上去

這一組沒什麼好討論的,直接貼。

Nginx

# 放在 server 區塊裡
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options    "nosniff" always;
add_header Referrer-Policy           "strict-origin-when-cross-origin" always;
add_header X-Frame-Options           "DENY" always;
add_header Permissions-Policy        "geolocation=(), camera=(), microphone=()" always;

🔑 那個 always 不能省。少了它,Nginx 只會在 2xx / 3xx 的回應加上標頭,錯誤頁面(404、500)就通通沒有防護。而錯誤頁面剛好是最常被拿來做攻擊測試的地方。

Node.js / Express

const helmet = require("helmet");
app.use(helmet());          // 一行就把上面這些預設值都設好了
app.disable("x-powered-by"); // 順手把 Express 的身分證拿掉

各標頭在做什麼:

標頭 作用 沒設會怎樣
Strict-Transport-Security 告訴瀏覽器「這個網域以後只准走 HTTPS」 使用者在咖啡廳被降級成 HTTP 攔截
X-Content-Type-Options: nosniff 禁止瀏覽器「猜」檔案型別 上傳的圖片被當成 JS 執行
Referrer-Policy 控制跳轉到外站時帶出多少來源資訊 完整網址(含參數)洩漏給第三方
X-Frame-Options: DENY 禁止被 <iframe> 嵌入 點擊劫持 (Clickjacking)
Permissions-Policy 關掉用不到的瀏覽器功能 被注入的腳本有機會存取相機/定位

💡 關於 X-Frame-Options,補一個容易搞混的點:它沒有被廢棄DENYSAMEORIGIN 都還能正常運作(只有 ALLOW-FROM 是過時的,現代瀏覽器會直接忽略整個標頭)。但 CSP 的 frame-ancestors 功能更完整,而且兩者同時存在時 frame-ancestors 優先

實務建議:兩個都設。新瀏覽器吃 frame-ancestors,老瀏覽器吃 X-Frame-Options

⚠️ HSTS 的三個坑

HSTS 是這組裡唯一有機會把你自己鎖在門外的標頭,所以單獨拉出來講:

  1. 它只在 HTTPS 連線下生效。瀏覽器會忽略透過 HTTP 送來的 HSTS 標頭;否則中間人只要偽造一個超長 max-age,就能對你做 DoS。
  2. includeSubDomains 會波及所有子網域。如果你有內部系統還跑在 http://admin.example.com,加上去的當下它就連不上了。先確認每一個子網域都上了 HTTPS 再加。
  3. preload 請最後再說。加入 preload 清單等於把你的網域寫進瀏覽器的原始碼裡,移除要等好幾個版本、數個月起跳。先用 max-age=300 跑幾天確認沒事,再慢慢拉長到一年、兩年,最後才考慮 preload。

🛠️ Step 3:CSP,今天唯一需要動腦的地方

先講一個會顛覆你認知的數字

Google 的研究團隊掃描了 168 萬個網站上的 CSP 設定,結論是:

在所有嘗試限制腳本執行的 CSP 政策中,94.68% 是可以被繞過的。
— Weichselbaum et al., CSP Is Dead, Long Live CSP!, ACM CCS 2016

而那 94.68% 裡,絕大多數用的就是你在幾乎每一篇教學文章上看到的那種寫法

❌ Content-Security-Policy: script-src 'self' https://cdn.example.com;

問題出在哪?你信任了一整個網域。而那個 CDN 上只要有任何一支可以被利用的檔案(一個舊版 AngularJS、一個 JSONP 端點),攻擊者就能借道它把腳本送進來。研究中最常被列入白名單的 15 個網域,有 14 個含有這種不安全的端點

你以為你設了一道牆,其實你開了一扇後門。

🔙 回頭看 Step 1 那張 owasp.org 的截圖。 把它實際的 script-src 撈出來,長這樣:

script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdnjs.cloudflare.com https://unpkg.com ⋯(後面還有十幾個網域)

沒有 nonce、沒有 strict-dynamic,只有一長串網域白名單,外加 unsafe-inline(直接放行行內腳本)與 unsafe-eval(允許把字串當程式碼執行)。

這正是上面那篇論文歸類為「可繞過」的教科書寫法:cdnjsunpkg 上面躺著數以萬計的函式庫版本,要從裡面翻出一支可以當跳板的舊檔案,並不困難。

所以 securityheaders 才把等級鎖在 A:標頭有設,但擋不住它本來該擋的東西。

這也是為什麼那個 94.68% 不是在說「大家都不裝 CSP」,而是在說:大部分裝了的人,裝的是一個過不了的濾網。

正確做法:nonce + strict-dynamic

要送出的回應標頭長這樣:

Content-Security-Policy: script-src 'nonce-{每次請求都不同的隨機值}' 'strict-dynamic' 'unsafe-inline' https:; object-src 'none'; base-uri 'none'

⚠️ 這一行不能折行。 很多文章為了排版好看,會把 CSP 拆成好幾行縮排呈現,但 HTTP 標頭的折行寫法(obsolete line folding)在 RFC 7230 已經被廢棄,現代伺服器與代理會直接拒絕。它看起來很長,但必須是單一一行

那個 nonce 從哪裡來?它必須由應用程式在每一個請求產生,因為同一組值要同時出現在標頭與 HTML 裡:

const crypto = require("node:crypto");

app.use((req, res, next) => {
    // 每個請求產生一組,稍後樣板也要用同一組
    res.locals.nonce = crypto.randomBytes(16).toString("base64");

    res.setHeader("Content-Security-Policy",
        `script-src 'nonce-${res.locals.nonce}' 'strict-dynamic' 'unsafe-inline' https:; ` +
        `object-src 'none'; base-uri 'none'`);
    next();
});

搭配 HTML({同一組隨機值} 就是上面那個 res.locals.nonce):

<script nonce="{同一組隨機值}">
  // 只有帶著正確 nonce 的腳本才會被執行
</script>

三個關鍵:

  • nonce:每次請求都重新產生。攻擊者注入的腳本猜不到這次的 nonce,就執行不了。這跟白名單最大的差別是,它信任的不是「來源」,而是「這段腳本確實是我這次產生的」
  • strict-dynamic:讓已經通過驗證的腳本可以自己再載入其他腳本(不然一堆前端框架會壞掉)。它還有一個關鍵的副作用:CSP3 瀏覽器只要看到 strict-dynamic,就會直接忽略 'unsafe-inline' 與後面那串 https: 白名單。

🤔 等一下,剛剛不是才罵 OWASP 有 unsafe-inline,怎麼我們自己也寫?

差別就在 strict-dynamic我們這行政策裡的 'unsafe-inline'https: 是寫給不支援 CSP3 的舊瀏覽器看的退路,新瀏覽器會直接無視它們、只認 nonce。而 owasp.org 的政策沒有 strict-dynamic、也沒有 nonce,所以它的 unsafe-inline真的生效的。

同一個關鍵字,在有沒有 strict-dynamic 的政策裡,意義完全相反。 這也是 CSP 難寫的原因:它的指令彼此會互相改寫語意,不能一條一條分開讀。

  • base-uri 'none':這一項超級容易漏掉。如果攻擊者能注入一個 <base href="https://evil.com">,你頁面上所有相對路徑的 script 就全部改從他家載入了,CSP 的其他設定完全擋不住這招。

🔑 重點在「同一組」這三個字。 標頭裡的 nonce 與 HTML 裡的 nonce 必須逐字元相同,而且每個請求都要換一組新的

這帶出一個很容易踩的坑:如果你的頁面有做整頁快取(CDN、反向代理、靜態產生),nonce 就會被凍結成同一組,攻擊者只要讀一次原始碼就知道下次要填什麼,整套防護等於歸零。用 nonce 的頁面不能整頁快取,這件事沒有折衷方案。

上線流程:先觀察,再執行

CSP 是唯一有機會把你自己的網站弄壞的標頭。所以絕對不要一次就上正式版:

第一步先送這個標頭,只回報、不阻擋,讓它跑幾天:

Content-Security-Policy-Report-Only: script-src 'nonce-xxx' 'strict-dynamic'; report-uri /csp-report

Report-Only 版本,違規行為只會記錄下來、不會被擋。等你看完報告、把自己網站上的問題都清乾淨了,再把標頭名稱改成正式的 Content-Security-Policy


🛠️ Step 4:刪掉那些會出賣你的標頭

這一步最容易被忽略,但它成本幾乎是零。

# Nginx:關掉版本號與目錄瀏覽
server_tokens off;
autoindex off;
// Express
app.disable("x-powered-by");
<!-- ASP.NET:web.config -->
<httpRuntime enableVersionHeader="false" />

還有一個特別的,X-XSS-Protection 請明確關掉

X-XSS-Protection: 0

沒錯,是設成 0,不是移除、更不是設成 1。這個標頭當年是用來啟動瀏覽器內建的 XSS 過濾器,但後來發現那個過濾器本身會在原本安全的網站上製造出漏洞,各家瀏覽器已陸續移除它。OWASP 現在的建議就是明確設成 0 停用。

🔑 這是一個很好的提醒:資安建議是有保存期限的。你在網路上查到的「必備標頭清單」,很可能是五年前寫的。而五年前的正確答案,今天可能剛好是錯的


✅ 驗收:用 curl 直接看

改完之後,不用等瀏覽器,一行指令就能確認:

curl -I https://你的網域

-I 只抓回應標頭。對照一下上面幾個 Step,該有的有沒有出現、該消失的 Server 版本號有沒有不見。

然後回到 Step 1 的 securityheaders.com 再掃一次,看分數有沒有跳上去。


🎯 今日重點小結與防守心法

  • 🔹 心法 1:先量測,再修改,最後回頭驗收。這是 Day 10 我們讀第一份掃描報告時建立的同一套習慣:沒有基準線,你不會知道自己有沒有進步
  • 🔹 心法 2:白名單式 CSP 是一種安慰劑。94.68% 可被繞過的數字說明了一切。要嘛認真用 nonce + strict-dynamic,要嘛就承認你只是在應付掃描器。
  • 🔹 心法 3:安全設定要「加」也要「減」。加上防護標頭、拿掉洩漏資訊的標頭,後者花不到五分鐘,卻能讓對手失去整個偵察階段的優勢。

💬 明日預告:【Day 20】【動手做】OWASP A07 認證失效:Session Cookie 與 JWT 防禦
今天我們把門窗鎖好了,明天要處理的是鑰匙本身。當使用者登入後拿到的那張憑證被偷走或被偽造,前面所有防線都會一起失效。


上一篇
【Day 18】【動手做】OWASP A05 注入攻擊:SQLi 防衛與參數化查詢
下一篇
【Day 20】【動手做】OWASP A07 認證失效:Session Cookie 與 JWT 防禦
系列文
槍林彈雨下的資安防守:從品質觀念切入,帶開發者從零動手作資安 30 天22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言